Skip to content

v0.12.0 — Slack, one agent session per thread (+ @-mention switch) - #211

Merged
firstintent merged 10 commits into
mainfrom
dev
Oct 5, 2026
Merged

firstintent merged 10 commits into
mainfrom
dev

Conversation

@firstintent

Copy link
Copy Markdown
Owner

Re-cut of v0.12.0. The first v0.12.0 release run (37221762018) was cancelled mid-build and its tag removed, so nothing was published (no GitHub release; npm still at 0.11.2). This PR brings the follow-ups that landed since, and the release is tagged after merge.

Since main (#210):

Slack (#19)

  • A regular user can run their own Slack bot, like Telegram and Lark (slack@<tenant>, /me/im).
  • Choose first, then talk: at a channel's top level, commands preset the next session and the next message starts it in its own thread. 🧠 walks harness → model → reasoning effort (the model's own levels, else the harness's; skipped where there is none).

One @-mention switch for every IM (#20)

  • Telegram / Lark / Slack, the owner's bots and each user's own, carry require_mention (default off = answer everything). On: in a group or channel the bot answers only messages that @-mention it; DMs, slash commands and buttons are always answered; on Slack, a thread the bot already holds continues without the @.
  • REST PUT /config/im/{platform}/require-mention, the tenant twin under /me/im, a new GET /me/im; a switch row on all six web cards. Re-saving tokens keeps the switch.
  • The tenant cards show what is saved after a reload (bound ids no longer dropped on the next save).

Build / install

  • make install builds a thin-LTO local-release profile (~6m → ~35s rebuild). Every install-dir ladder (install.sh, ccteam update, status/doctor's install channel, the DSH plugin) treats it as a build tree; embedded plugin tarball re-packed (two-path reproducible).

Tests

  • Two tests/*.rs checks that drifted outside the CI baseline fixed (F168 anchors all closed; dsh_bin test isolated from the host's npx cache).

Local gate: fmt clean · clippy 0 warnings (rustc 1.99.0) · make test-baseline 2492 passed / 0 failed · make test 3218 passed, 1 failed (body_record_follows_spawn_close_and_detach, full-parallel timing only: 3/3 alone, 38/38 ×2 as a file) · ccteam-web 480 passed + the 3 known ws_* PTY tests · SPA lint + 790 · plugin 289/289.

🤖 Generated with Claude Code

firstintent and others added 10 commits October 5, 2026 02:10
…d Lark (#19)

Slack was configurable only by the owner (the global bot under Settings →
Access); a regular user's "My IM bot" section offered Telegram and Lark
but no Slack, so the platforms were not symmetric.

A tenant now has an optional `TenantSlack` beside `TenantTelegram` /
`TenantLark`, and everything the other two get, Slack gets:
- the daemon runs a `slack@<tenant>` channel per configured tenant —
  fail-closed until a member is allowed, its rejected senders in the
  shared probe file, and Slack's thread-per-session and button contracts
  under that name;
- `PUT /me/im` takes a `slack` block (both tokens checked with Slack
  before anything is written; a token change keeps the bound members),
  plus `GET /me/im/slack/user-id-candidates` (scoped to the caller's own
  bot) and `PUT /me/im/slack/allowed-users`;
- `chat_send_file` resolves a tenant's Slack as a delivery target;
- "My IM bot" gets a Slack card with the owner card's three steps. The
  create-app manifest route is open to every signed-in identity — it
  holds no secret — while the global bot's capture/allowlist stay
  owner-only.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
… session (#19)

Owner feedback: a channel showed nothing but command echoes — every
`/ccteam new …` posted an anchor and the session lived in its thread, so
the session's real first message became a reply and the channel never
said what any session was about.

In a channel that threads sessions, a command typed at the CHANNEL level
no longer opens a session in its own place. `/new …` and `/use <sid>`
preset the conversation's NEXT session (validated at once; the preset
is a visible channel message, so everyone sees which model each session
was set up with), and the next top-level message starts — or brings
over — that session in its own thread, so the thread opens with that
message. `/cd` there switches the project without adopting a session.

The channel menu (`/ccteam` alone) is buttons first: 📁 projects ·
🧠 model · 🧵 sessions · 📊 status; 🧠 walks harness → model (the
advisory model catalog) and presets with a tap. Taps on a channel-level
message act at the channel level too, so 🧵 sessions presets continuing
the picked one.

The Slack provider stops posting an anchor for a slash command, sends a
top-level `!command` and a tap on a top-level message at the channel
level, and keeps one safety net: a reply the bot cannot post (not in the
channel) is explained through the command's ephemeral response. The
daemon reports `session_threads` to the gateway with the button
capability. Telegram, Lark and web are unchanged.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…ly when @-mentioned (#20)

Telegram, Lark and Slack each got an inline (or no) group @-rule, none
configurable, so two ccteam bots in one Slack channel both answered every
message from a member allowed on both. Fix it once, below the providers:

- Every IM credentials block (and each tenant's own bot) carries
  `require_mention` (default false = answer everything).
- Providers only report a fact, `ChannelMessage.ambient` (a group/channel
  message that does not address the bot: Telegram @bot / /cmd@bot / reply,
  Lark mentions, Slack <@bot>); `MentionPolicy` is the single rule that
  decides what an ambient message does.
- Provider-side early drop before any attachment download for ambient
  messages outside a thread; the daemon's inbound consumer is the final call,
  admitting an ambient Slack thread reply only when the gateway holds a
  session in that thread — or an addressed message already claimed it (its
  session may still be spawning off the loop). Decided before the security
  layer so chatter never spends the sender's rate-limit budget.
- Telegram strips the bot's own handle from group text so `@bot /new codex`
  is still a command; Lark's hard-coded "group needs an @" becomes this
  switch (default now matches the other IMs).
- REST: `require_mention` in the masked status; new `GET /me/im`; one
  parametrised `PUT /config/im/{platform}/require-mention` and the tenant
  twin. Re-saving tokens (web, tenant, `ccteam config`) keeps the switch.
- SPA: one switch row on all six cards (owner + "my IM bot" x 3).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
… cut (#20)

Checker review of ac14ebb flagged a possible underflow in the Telegram
handle-cut arithmetic. It is unreachable (a cut is only computed when the
span ends with this bot's own @username, so it is at least that long), so
no code change — a regression test now pins it, and the claimed-threads
comment in the inbound consumer says what a full reset can actually lose.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Owner feedback: picking a model from the channel menu presets the next
session at once, so there was no way to choose its reasoning effort from
the menu (only by typing `/new <vendor> model= effort=`).

After a model, the menu now offers that model's effort levels — its own
declared set when it has one, else the vendor's ladder, the same rule the
web composer uses (`effortRowsFor`) — with "default effort" first, which
wires nothing. A harness with no effort axis (opencode) is not asked, so
no menu that does nothing. Each tap lands in the same preset a typed
`/new` makes; `new-effort:<effort>:<vendor>[:<model>]` leads with the
effort because a model id may contain a `:` and an effort never does.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
The tenant Telegram / Lark / Slack cards only tracked "done" in local
component state and used the `/me/im` status for the @-mention switch
alone, so a reload rendered a configured bot as unconfigured. Worse, the
allowlist editors started empty while the allowlist PUTs replace the
whole list, so binding one more id after a reload dropped the ones
already bound.

`/me/im` (and `/config/im`) now also return the bound Telegram chat ids
and Lark open_ids (not secrets, as Slack member ids already were); the
cards take every step's state, token fingerprints and allowlists from
the server status. Tokens are still never echoed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`make install` built the shipped `release` profile (fat LTO, one codegen
unit), whose final single-threaded LTO step dominated a local rebuild.
The new `local-release` profile inherits release with thin LTO and 16
codegen units: a code-only rebuild drops from ~6m to ~35s on the dev box.
`release` stays the published artifact's profile; install.sh treats the
new build dir as a build tree, not an install location.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tree

cb1c3a1 taught install.sh that `target/local-release` (the thin-LTO
profile `make install` now builds) is a build tree, not an install
location, but its three copies did not follow: `ccteam update`'s
`resolve_install_dir_with`, the install-channel classifier behind
`ccteam status` / `doctor`, and the DSH plugin's engine installer. So
`ccteam update` run from a local-release build would install back into
the build tree, and status would call that binary "other" with no update
hint. Both drift guards (Rust and plugin) caught it.

The profile list is now one constant in ccteam-core
(`install_channel::CARGO_BUILD_PROFILES`) used by both Rust readers; the
plugin mirrors it and install.sh keeps the shell copy, each still held in
step by its drift test. Embedded plugin tarball re-packed (two-path
reproducible 4eadf708…).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
- no_silent_todo: Slack over Socket Mode (#19) delivered the last three
  F168 `TODO(V0.7-…)` anchors, so the "exactly three survive" check
  failed on a clean tree. All six are closed; assert none remain.
- dsh_acp: `dsh_bin()` falls back to a copy the user's own `npx dsh`
  cached under `~/.npm/_npx`, so the "absent override → `dsh`" test
  failed on any host that has one. Pin HOME to an empty dir.

Both live in `tests/*.rs`, which `make test-baseline` (= CI) skips.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
#19, #20)

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-05T16:12:52.566323Z e97ff5e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@firstintent
firstintent merged commit 47d0fd6 into main Oct 5, 2026
3 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e97ff5eaa1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1513 to +1515
let held = match &thread_key {
Some(key) if addressed_threads.contains(key) => true,
Some((_, _, t)) => gateway.lock().await.has_current_session(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expire addressed-thread claims after the spawn window

When require_mention is enabled, every addressed Slack message inserts its thread into addressed_threads, but entries are never removed except when the entire 4096-entry set is cleared. Consequently, after an addressed /stop removes the thread's current-session route—or after an addressed command such as /help creates no session—later ambient replies are still treated as held and can spawn a new session without mentioning the bot. Limit this claim to the in-flight spawn window or remove it once routing finishes.

Useful? React with 👍 / 👎.

Comment on lines +798 to +801
let ambient = group && !bot.is_some_and(|b| addresses_bot(&m, b));
if self.mention_policy.drops_early(ambient, false) {
tracing::debug!(chat_id = %chat_id, "telegram: ambient group message dropped (require_mention)");
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let bare Telegram commands bypass mention gating

With require_mention enabled in a Telegram group, a normal bare command such as /status is classified as ambient because addresses_bot recognizes a bot_command only when it has an @bot suffix. The new early-drop therefore discards these slash commands, even though commands are intended to remain available regardless of the mention switch. Treat Telegram bot_command entities as addressed, or exempt commands before applying this gate.

Useful? React with 👍 / 👎.

firstintent pushed a commit that referenced this pull request Oct 14, 2026
…the same sid

After `ccteam daemon restart`, a claude stream-json body that still has a
background task running (a run_in_background dev server, a watcher) does
not exit on stdin EOF: real claude 2.1.296 stays alive until that task
ends and then self-continues. The new daemon registers it DETACHED and
waits for it, which is unbounded, so every message to the sid queues
behind it forever. The receipt pointed at `/stop`, which ends the session:
it sets stopped_at, drops the chat's focus routes (the next message mints
a new sid) and strands the queued turns. The session looked permanently
lost.

`/interrupt` (IM and `POST /api/v1/sessions/{sid}/interrupt`) on a
detached sid now ends only that process. The DETACHED entry, the queue
and the focus routes stay, and the body watcher takes the same path it
takes for a body that exits on its own: it recovers what the body said,
rebuilds the sid with --resume (same context) and drains the queued
turns. The receipt names this way out, and the usage docs describe it.

Refs docs-local issue #211.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firstintent pushed a commit that referenced this pull request Oct 14, 2026
…en back" from

When `<uuid>.jsonl` exists, start_thread resumed and, on any failure, fell
back to a fresh `--session-id <uuid>` on the SAME uuid. Real claude refuses
that ("Session ID … is already in use", exit 1), so the fallback could never
start. It turned one failed resume into a second failure, and it logged a
chat_session_reset claiming the context was lost when the transcript was
intact.

The local arm now returns the resume failure itself, as a readable,
retryable error. Nothing sticks: the transcript is untouched, the killed
handshake child's body record is cleared (so the next spawn does not
post-mortem a turn that never ran), and the next message tries --resume
again. The remote fallback stays: there the transcript's existence is only
a guess.

Refs docs-local issue #211.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firstintent pushed a commit that referenced this pull request Oct 14, 2026
A detached body was waited for until it exited, and real claude stays
alive after stdin EOF for as long as a background task it started is
running. A session nobody steered by hand (a delegated child, a
scheduled run, an unwatched chat) stayed stuck behind it, with every
turn queued and none delivered.

The body watcher now ends the wait on its own, but only while a turn is
queued for that sid. A body nobody is waiting on is left alone, so a job
it scheduled for later still runs. The adapter reads the vendor record
(`detached_activity`; for claude, the tail of the transcript plus the
subagent transcripts beside it):
- last turn ended and quiet for 30s: the process is kept alive only by
  background work and can never take the turn;
- a turn still open but silent for 30 min: treated the same way (no
  record: 30 min since the body was detached).
A body that is mid-turn and still writing keeps the wait.

The process is ended with one SIGKILL to the recorded pid
(`session_body::kill_body`), and the same path is used for `/interrupt`.
SIGTERM is not used: claude answers it by killing every background task
it started (measured on 2.1.296), and those tasks are the user's dev
servers, stacks and scheduled jobs. With SIGKILL they keep running,
reparented to init, and ccteam signals nothing else. Only the process
ends, never the session: sid, ledger and focus routes stay, and the
watcher's natural-exit path resumes the same sid with --resume and
delivers the queued turns. The ledger records the reason as
`ended_idle` or `ended_stalled`.

Refs docs-local issue #211.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firstintent pushed a commit that referenced this pull request Oct 14, 2026
A. Bodies are spawned as their own process-group leaders. Ending one
   (stop, /interrupt, or the automatic bound) now signals the group:
   SIGTERM, then a group SIGKILL that also sweeps members which outlived
   the leader. So no orphaned dev server is left holding a port that the
   resumed session needs. A body that does not lead its own group (spawned
   by an older build, inside the daemon's group) has only its own pid
   signalled. The receipt, the IM reply and the docs now say the
   background processes end with it.
B. Every path that brings a session back drains its FIFO before any new
   line is submitted: the backlog goes out first, in order, on the next
   message. If the watcher's rebuild fails after a body ends, the reply
   target is told why and that the queued messages are kept.
D. When a line was queued for a dead child and the resume then failed, the
   line is withdrawn. The error says the message was NOT delivered (so
   resending cannot deliver it twice) and names `/new` as the way out.
   The harness error no longer gives advice of its own.
C. The ledger records why a detached body ended: `interrupted`,
   `auto_recovered` or `exited`. The recovered-answer and unrecoverable
   notes are worded for the reason recorded.
Minors: the kill never runs under the gateway lock. REST /interrupt and
   /stop await it off the lock. The IM /interrupt door hands it to its own
   task, which wakes the watcher when done. A failed REST interrupt now
   returns 502 with the cause instead of 404 "unknown session".

Refs docs-local issue #211.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firstintent pushed a commit that referenced this pull request Oct 14, 2026
…d says so

Owner decision: ending a detached body to keep its session (`/interrupt`,
or the automatic bound) ends ONLY the body's process. It is one SIGKILL to
the fingerprint-checked pid (`session_body::kill_body`). claude answers
SIGTERM by killing every background task it started, and those tasks are
the user's dev servers, stacks and scheduled jobs. An explicit `/stop`
still ends the whole process group: the session is over, and so is its
work.

The background processes are never signalled, but they are made visible.
Before the kill, the body's descendants are walked in /proc and
fingerprinted. Each one still running afterwards is recorded as `orphans`
(pid + short cmdline) on the `session_body_exited` ledger event, and the
reply target is told: "still running, unattended: pid 1234 `npm run dev`".
A port the resumed session cannot bind is no longer a mystery. The ledger
reasons are `interrupted`, `ended_idle` and `ended_stalled`. The receipt,
the IM reply and the docs say the background processes keep running.

Refs docs-local issue #211.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firstintent pushed a commit that referenced this pull request Oct 14, 2026
…t sticky

D1. end_detached_body_shared no longer overwrites an existing end mark.
    The watcher decides off the lock, so it could reach a body already
    marked by a user's /stop and relabel that end as its own. The watcher
    would then rebuild the session the user had just stopped and drain the
    queue into it. Only a stop may take over an interrupt. The exit handler
    leaves a stop-marked body to the stop. A stop also drops the sid's
    rebuild reservation, so a rebuild already spawning off the lock is
    discarded rather than installed after the stop.
D2. When the &mut /interrupt door's kill task fails, it flags the sid. The
    next reader of the mark lifts `interrupted`, so a later /interrupt
    really ends the body and a later natural exit is not recorded as an
    interrupt.
Gap 1. When the watcher's rebuild fails, a waiting delegation parent now
    gets an honest failure through the normal completion path
    (vendor_error, error_kind resume_failed), not just the reply target.
Gap 2. Project delete ends detached bodies off the gateway lock before
    stopping their sessions.

Refs docs-local issue #211.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firstintent pushed a commit that referenced this pull request Oct 14, 2026
N1. A detached body's end is now one hand-over entry per sid: in
    progress, then ended (with what it left running) or failed. The
    watcher, and the exit handler in the same lock as it removes the entry,
    defer while a kill has not handed over. So the report never goes out
    without the orphans, and a late hand-over can no longer be reported for
    a later incarnation. Each orphan carries its start-time fingerprint and
    is re-verified when reported, so a reused pid is never named. A stop
    drops any stale entry for the sid.
N2. Orphan command lines are redacted before they reach IM or the ledger:
    `key=value` and `--key value` for keys naming a token, secret,
    password, passwd, key, auth or credential; `Bearer` values; URL
    credentials; known token prefixes (sk-, ghp_, gho_, github_pat_,
    xox?-, AKIA); and runs of 32+ base64/hex characters. Control
    characters and backticks are replaced and the home directory reads as
    `~`. For claude's Bash wrapper, the eval payload is shown instead of
    the wrapper.
N3. signal_group's doc is back on signal_group. kill_body re-verifies the
    body right before the SIGKILL, because the /proc walk widens the
    pid-reuse window. Descendants take their start time from the same
    /proc/N/stat read. The doc no longer claims that no transcript line is
    cut: /interrupt and ended_stalled can tear the last line, which claude
    resumes past.
D1 stays as committed in 9e92aa4: an existing mark is never overwritten,
except that a stop may take over an in-flight interrupt.
Docs: /stop ends the session's current process and that process's
background processes; ones left by an earlier /interrupt or automatic end
are not touched.

Refs docs-local issue #211.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firstintent pushed a commit that referenced this pull request Oct 14, 2026
…header secrets

C1. A body's kill and its hand-over now run on their own task. Shared
    callers (REST /interrupt, /stop, project delete) await the task's
    JoinHandle, so a web client that disconnects mid-kill no longer leaves
    `InProgress` behind forever, with the sid stuck detached and its queue
    undelivered. Each `InProgress` carries a token and a start time: only
    the kill that set it writes the outcome, and one older than 30s is
    settled as a failed end.
C2. The redactor now also covers:
    - quoted values (`--password='x'`, `TOKEN="x"`, `--token "x"`);
    - header and JSON members (`X-Api-Key: x`, `{"token":"x"}`) and Basic
      credentials;
    - `curl -u user:x`, mysql/mariadb `-px` and `sshpass -p x`;
    - `pass` and a whole-word `pwd` as sensitive keys;
    - URL userinfo with an empty user (`redis://:pw@h`).
    Every example from the review is in the test table.
Minors. If a stop finds its body already gone (an /interrupt's SIGKILL got
    there first), it still sweeps the body's process group, but only when
    a member still carries the sid in its environment. When the watcher's
    rebuild fails, the delegated tasks queued for the sid are withdrawn as
    their parents are told; a parent's resend is then the only run, never
    a late second completion. A human's queued lines stay.

Refs docs-local issue #211.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firstintent pushed a commit that referenced this pull request Oct 14, 2026
… path

When the watcher cannot bring a session back, the delegated tasks queued
for it are withdrawn. Their resume_failed signal used to carry no
execution turn, and plan_delegation_delivery answers a boundary nobody's
request is bound to with nobody: the parent was never told, and the
request stayed outstanding in delegation.json forever. The withdrawn
requests are now bound, durably and under the child's store claim, to a
turn of their own. That turn is delivered through the ordinary completion
path, so the notification has the same identity matching, the same
two-phase durability and restart dedup, and the same submission (a live
parent is steered, a released one gets the pending FIFO and a resume) as
any completion. Each parent hears once that the task was withdrawn and
did not run.

A REST /stop now runs the whole stop on its own task. A client that
disconnects mid-kill no longer leaves a dead body marked `stopping`
forever with messages queued behind it.

Redaction adds Cookie / Set-Cookie header values, `docker login … -p`,
and `echo|printf SECRET | … --password-stdin`.

Refs docs-local issue #211.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant